Lambda@EdgeでAWS Systems Managerのパラメータストアを使う
前回の記事でLambda@Edge(Python)を使って、IPアドレスによるCloudFrontへのアクセス制限を紹介しました。(Pythonで書いたことを紹介するのが趣旨でしたが)
Lambda@Edgeでパラーメーターをベタ書きする課題
前回の記事のスクリプトでは、許可対象のIPアドレスをコード内に直接記述したものでした。
個人で利用するような場合やテスト利用のようなケースだとあまり問題にはならないかもしれませんが、次のような課題があります。
- 頻繁にIPアドレスを変える場合や、関連付けているビヘイビアなどが多い場合、更新作業がめんどくさい。
- 更新したLambdaをCloudFrontに関連付け直す作業が発生します。(これが地味に手間になります)
- IPアドレスの変更だけでもCloudFrontの変更完了まで時間がかかる。
- 最近はCloudFrontの変更も非常に短時間で済むようになり、10分もかからない程度になりましたが。
- Githubなどでコードを管理していて、IPを公開したくないのに誤って公開してしまう恐れがある。
- (IPアドレスの秘匿性の程度については状況次第で変わると思いますが...)
パラメーターを別のサービスで管理
そこで、直接Lambdaに書かなくて済むようにしたいと思います。色々と方法はあるので全てではありませんが次のようなものが考えられます。
- Lambdaの環境変数を使う
- Secrets Managerを使う
- Systems Managerのパラメーターストアを使う
Lambdaの環境変数を使う場合
Lambda@Edgeではない、通常のLambdaであれば環境変数を使うことができます。またデータをKMSで暗号化することも可能なので、セキュアにパラーメーターを扱うことが可能です。
しかし、2020年10月20日現在では、Lambda@Edgeは環境変数をサポートしていません。そのため環境変数を使う案は却下です。
Secrets Managerを使う場合
Secrets Managerは、DBのパスワードや各種認証情報などシークレットな情報を一元管理することができるサービスです。特徴の一つとして、設定したスケジュールに基づきシークレット情報を更新することができます。
また、「シークレット単位」および「10,000回のAPIコール単位」で課金が発生します。
Systems Managerのパラメーターストアを使う場合
Secrets Managerと同じようにパスワードなどのシークレット情報などを一元管理できるサービスです。
パラメーターストアには、「スタンダード」と「アドバンスド」の2種類が利用できますが、「スタンダード」なら基本的に無料で利用できます。格納できるパラメーター値のデータサイズなどの違いがあります。
パラメーターストアをLambda@Edgeで使う
今回はアクセスも頻回ではなく、許可対象のIPアドレス数も多くはないので、Systems Managerのパラメーターストアを使うことにしました。
注意点としては、今回の使い方の場合はLambdaが実行される都度パラメータストアを参照するので、頻度が多いとパラメータストア側のレートリミットに引っかかる可能性があります。
(普通のサイトならブラウザでアクセスした場合だと複数同時に実行されるので、より注意が必要となります)
今回は、開発環境でアクセス数が少ないことが前提なので、このような形を採りました。 なお、パラメーターストアはKMSで暗号化することもできますが、今回は暗号化せずに使うことにします。
パラメーターストアを作成
最初にパラメーターストアを作ります。作成するのはどこのリージョンのSystems Managerでも構いませんが、今回はLambdaと同じバージニアリージョンで作成しました。
(実は作成するリージョンが重要なのですが、詳細は「Lambda関数の作成」の節で説明します)
名前、利用枠、データ型、値をそれぞれ設定します。値には許可対象とするIPアドレスをカンマ区切りで入力しました。(Lambda関数の仕様に合わせる為)
IPアドレスは前回の記事と同様にサンプルのIPのみとしています。
LambdaのIAM Role
前回作成したLambdaのIAM Roleにパラメータストアを参照できる権限を追加します。
下記のようなポリシーを作成してアタッチします。ポリシー名は適当で構いません。(lambda-ssm-parameter-store-access
という名前で作成しました)
{ "Version": "2012-10-17", "Statement": [ { "Sid": "VisualEditor0", "Effect": "Allow", "Action": [ "ssm:GetParameters" ], "Resource": "*" } ] }
このポリシーを既存のLambdaのロールに追加でアタッチします。
Lambda関数の作成
前回の関数を少し更新していますが、ポイントは3行目と20-24行目です。
import json import boto3 ssm = boto3.client('ssm',region_name='us-east-1') CONTENT = """ <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <title>403 Forbidden</title> </head> <body style="background-color:lightpink;"> <h1>This is an Error Page</h1> <p>Your IP is not allowed to access this site!</p> </body> </html> """ def lambda_handler(event, context): ssm_response = ssm.get_parameters( Names = [ 'IP_WHITE_LIST' ] ) allowed_ip_list = ssm_response['Parameters'][0]['Value'].split(',') print("WHITE LIST: ", allowed_ip_list) request = event['Records'][0]['cf']['request'] client_ip = event['Records'][0]['cf']['request']['clientIp'] print("Client IP: ", client_ip) #if client_ip in IP_WHITE_LIST: if client_ip in allowed_ip_list: return request # return 403 response when clientIP doesn't exist in the whitelist response = { 'status': '403', 'statusDescription': 'Forbidden', 'headers': { 'cache-control': [ { 'key': 'Cache-Control', 'value': 'max-age=100' } ], "content-type": [ { 'key': 'Content-Type', 'value': 'text/html' } ], 'content-encoding': [ { 'key': 'Content-Encoding', 'value': 'UTF-8' } ] }, 'body': CONTENT } return response
該当箇所の抜粋です。
ssm = boto3.client('ssm',region_name='us-east-1')
Lambda@Edgeの場合、関数はバージニアリージョンで作成しますが、実際にはアクセスされたクライアントに近いエッジロケーションで実行されます。
その際、下記のような記述だと関数が実行されたリージョンのパラメーターストアを参照してしまうので、該当のパラメーターストアが無ければエラーとなります。
ssm = boto3.client('ssm')
全リージョンのパラメータストアに同じ情報を登録しても可能ですが、現実的ではありません。
そのため、明示的にどこのリージョンのパラメーターストアを使うのかを指定して実行するようにしています。Boto3を利用する際は、詳細を下記ドキュメントでご確認ください。
20-24行目ですが、今回はホワイトリストの変更を即確認したかったのと、開発環境でアクセスが少ないことから、パラメータストアの参照処理をハンドラの中に書きました。
ssm_response = ssm.get_parameters( Names = [ 'IP_WHITE_LIST' ] )
ただ、本来はハンドラの外に書いてグローバル変数に格納し、過度なパラメータストアへのリクエストを抑えた方がよいかと思います。本章の冒頭でも書いたようにレートリミットに引っかかる可能性もあるためです。
即時反映する必要がなければハンドラ外に書いた方がいいかと思います。即時反映しなくても少し待てば新たなLambdaのインスタンスで実行されて結果が反映されます。
詳細は下記記事を御覧ください。
動作確認
それでは動作確認してみます。(日本国内からアクセス)
画像の通り、アクセスがLambda@Edgeにより拒否されたことが確認できました。
次に許可IPを追加してみましょう。先程のパラメータストアにIPを追加します。
(xx.xx.xx.xxですが、実際には許可したいIPを記載してください)
改めて確認すると、S3のコンテンツを参照することができました。Lambda関数を更新するよりも早くすぐに動作確認することができました。
ちなみに、CloudWatch Logsで動作の詳細を確認する場合、今回は国内からアクセスしているので東京リージョンのCloudWatch Logsを参照します。
[オマケ]ホワイトリストのIPに管理情報を付与したい
このままだとパラメータストアに記載したホワイトリストの各IPアドレスが「どこのIPなのか」が分かりません。
なので、下記のようにパラメータストアの内容を json形式に変更して、IPアドレスの情報を付加できるようにしてみました。
(下記は実際の弊社のIPではありませんのでご注意ください)
{ "203.0.113.78": "cm-tokyo", "198.51.100.29": "cm-osaka", "192.0.2.221": "partner" }
Lambda関数は、json形式で取り込んだものを辞書型で扱えるように少し修正しました。
import json import boto3 ssm = boto3.client('ssm',region_name='us-east-1') CONTENT = """ <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <title>403 Forbidden</title> </head> <body style="background-color:lightpink;"> <h1>This is an Error Page</h1> <p>Your IP is not allowed to access this site!</p> </body> </html> """ def lambda_handler(event, context): ssm_response = ssm.get_parameters( Names = [ 'IP_WHITE_LIST' ] ) allowed_ip_list = ssm_response['Parameters'][0]['Value'] print("WHITE LIST: ", allowed_ip_list) mydict = json.loads(allowed_ip_list) request = event['Records'][0]['cf']['request'] client_ip = event['Records'][0]['cf']['request']['clientIp'] print("Client IP: ", client_ip) if client_ip in mydict: return request # return 403 response when clientIP doesn't exist in the whitelist response = { 'status': '403', 'statusDescription': 'Forbidden', 'headers': { 'cache-control': [ { 'key': 'Cache-Control', 'value': 'max-age=100' } ], "content-type": [ { 'key': 'Content-Type', 'value': 'text/html' } ], 'content-encoding': [ { 'key': 'Content-Encoding', 'value': 'UTF-8' } ] }, 'body': CONTENT } return response
パラメータストアで「どこのIPアドレスなのか」も記載できるようになったので分かりやすくなりました。
最後に
これでアクセス制限したいIPが変わっても、パラメーターストアのホワイトリストを更新するだけで利用できるようになりました。
もっといいアイデアやスクリプトの書き方等あると思いますので、参考程度に眺めてもらえれば幸いです。
以上です。